Skip to content

Reuse a single per-file diskcache handle when streaming remote files - #15049

Draft
jamalex wants to merge 2 commits into
learningequality:release-v0.19.xfrom
jamalex:fix-remote-file-diskcache-reuse
Draft

Reuse a single per-file diskcache handle when streaming remote files#15049
jamalex wants to merge 2 commits into
learningequality:release-v0.19.xfrom
jamalex:fix-remote-file-diskcache-reuse

Conversation

@jamalex

@jamalex jamalex commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

Addresses per-chunk disk-based lock cache churn when streaming content from another library, by re-using a cache across operations within a RemoteFile/ChunkedFile. This was most impacting devices with slow disk I/O, like an RPi running off of an SD card, rendering peer or Studio library browsing unusably, unreasonably, and unnecessarily slow.

References

Fixes #15048.

Reviewer guidance

  • kolibri/utils/file_transfer.py:299 _open_cache — the memoized handle is stamped with a chunk-dir "incarnation" (_chunk_dir_incarnation, :256) and dropped/reopened when it changes; confirm the token stays stable while chunk files are written into the dir (it uses the inode alone, deliberately not mtime/ctime, which would churn) yet changes on delete+recreate, and that a borrowed handle is dropped without being closed.
  • kolibri/utils/file_transfer.py:331 _initialize no longer resets self.position; confirm no caller other than __init__ relied on that reset (it's now reachable mid-read via ensure_writable).
  • kolibri/utils/file_transfer.py:179 _do_file_eviction now skips a directory it cannot remove; confirm skipped directories aren't counted as freed and the pass still continues.

AI usage

Used Claude Code to profile the regression on the affected Raspberry Pi, implement the fix, and write the regression tests. Verified with the full file_transfer and chunked_file test suites, a before/after benchmark on the device, and an adversarial local code review. (I wrote the Summary myself manually using my fingers, as per decree.)

@github-actions github-actions Bot added DEV: backend Python, databases, networking, filesystem... SIZE: medium labels Jul 22, 2026
@jamalex

jamalex commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

Measured speedup on a Raspberry Pi (SD-card storage)

Reproduced and verified on the affected device.

Setup: Raspberry Pi (4 cores, ~8 GB RAM), Kolibri 0.19.5, content storage on the SD card (/dev/mmcblk0p2). Browsing the built-in "Kolibri Library" remote source (Studio) over the LAN.

Method: fetched fresh/uncached proxied files (/content/storage/...?baseurl=https://studio.learningequality.org) through the local server with curl, 6 at once to mimic a browser opening a thumbnail grid plus a video. Identical files and concurrency before and after; server restarted between runs.

Before vs after (6 concurrent, uncached)

File Before (0.19.5) After (this PR)
13.7 MB video 498.8 s (8.3 min) ~1.3 s
34 KB thumbnail 49.6 s ~1.5 s
34 KB thumbnail 50.5 s ~1.9 s
36 KB thumbnail 48.9 s ~1.4 s
34 KB thumbnail 51.0 s ~2.0 s
35 KB thumbnail 50.9 s ~2.0 s

A 185-byte subtitle file took 37 s in an earlier before-run purely from contending with a concurrent download, which was clear evidence this was storage I/O contention, not bandwidth or file size. After the change the whole batch finishes in a couple of seconds, and the remaining time is used by actual network download, not overhead (two after-runs measured 4.1 s and 1.3 s for the video as upstream conditions varied).

Why

py-spy on the running server showed request threads stuck not in the network but inside diskcache opening/closing a SQLite cache in ChunkedFile._open_cache, on essentially every read block while streaming. Each open is a SQLite connection + PRAGMAs + fsync:

diskcache open/close this Pi's SD card tmpfs (RAM)
single-threaded 27.6 ms 2.2 ms
6 concurrent 106.8 ms 19.7 ms

A 13 MB file triggered ~415 of these opens (about 4 per chunk); this PR reuses one handle per file, so a full stream now opens the cache once regardless of size (verified). On SSD/NVMe the repeated opens are microseconds, which is why it never showed up on desktop installs; it only hits us on slow storage like an SD card.

@jamalex
jamalex force-pushed the fix-remote-file-diskcache-reuse branch from e15debd to 4c917ff Compare July 22, 2026 22:02
@learningequality learningequality deleted a comment from github-actions Bot Jul 22, 2026
@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

jamalex added 2 commits July 22, 2026 17:25
…te files

Streaming a proxied remote file (RemoteFile) reopened the per-file chunk
diskcache on essentially every read block, which is cheap on fast storage but
pathologically slow on SD cards. Cover:

- the diskcache is constructed a bounded number of times regardless of file
  size (not once per chunk)
- concurrent readers of the same file, and single-read correctness
- the handle self-heals when the chunk directory is evicted and recreated
  mid-stream, including dropping a borrowed handle without closing it
- the self-heal mechanism and Windows-safety invariant cross-platform:
  _open_cache reopens on an incarnation change, and delete() releases the
  handle before removing the directory
- the marker-file fallback used when the filesystem reports no inode
- eviction skips a directory that cannot be removed rather than aborting

The end-to-end deletion flavors are POSIX-only, since Windows forbids removing
a directory whose diskcache is held open; tests release the handle before
removing a directory so it does not block removal there.

Claude-Session: https://claude.ai/code/session_01MnAmUbaCBpL4wBKFYUyCwt
…read block

ChunkedFile opened a fresh diskcache.Cache on every cache access, and while
streaming a proxied file RemoteFile spawns a FileDownload per chunk, so serving
one file constructed the cache O(file size) times. Each construction opens a
SQLite connection (pragmas + fsync), negligible on SSD/NVMe but collapsing
proxied browsing on SD-card devices: a 13.7MB file took ~8 minutes under
concurrency and 34KB thumbnails ~50s; both drop to ~1-4s with this change.

Memoize the Cache handle on the ChunkedFile and reuse it for the object's
lifetime, and let a RemoteFile share its handle with the FileDownload streaming
into the same file, so a full stream constructs the cache once regardless of
size.

To keep the memoized handle correct when the chunk directory is deleted and
recreated (e.g. streamed cache eviction racing a stream), _open_cache stamps
each handle with a per-incarnation token - the directory inode, or a marker
file on filesystems without a usable inode (FAT/exFAT, some Android) - and
reopens when it changes, restoring the self-healing that opening per access
gave for free.

Because the handle now stays open for the stream's lifetime, release it before
removing the directory: delete() closes it first (an open SQLite file blocks
removal on Windows), and eviction skips - rather than aborts on - a directory
it cannot remove. Also stop resetting the read position in _initialize (now
reachable mid-read via ensure_writable, where it would rewind an in-progress
read).

Claude-Session: https://claude.ai/code/session_01MnAmUbaCBpL4wBKFYUyCwt
@jamalex
jamalex force-pushed the fix-remote-file-diskcache-reuse branch from 4c917ff to 98c42b1 Compare July 23, 2026 00:26
@rtibbles rtibbles self-assigned this Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DEV: backend Python, databases, networking, filesystem... SIZE: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants